Skip to content

feat: support deployment behind a reverse proxy under a sub-path#9306

Merged
lstein merged 9 commits into
invoke-ai:mainfrom
Pfannkuchensack:fix/proxy_usage
Jul 17, 2026
Merged

feat: support deployment behind a reverse proxy under a sub-path#9306
lstein merged 9 commits into
invoke-ai:mainfrom
Pfannkuchensack:fix/proxy_usage

Conversation

@Pfannkuchensack

Copy link
Copy Markdown
Collaborator

Summary

The web frontend derived all URLs from window.location.origin and used hardcoded paths (/api, /ws/socket.io, /locales, /openapi.json), so running Invoke behind a reverse proxy under a sub-path (e.g. https://host/invoke/) broke because the prefix was lost.

Frontend: adds a shared helper (common/util/baseUrl.ts) that auto-detects the deployment prefix from the entry bundle URL (import.meta.url), relying on Vite's existing base: './'. getBaseUrl(), the socket.io path, the i18n loadPath, the openapi fetch and the React Router basename now all use it. At the domain root the prefix is empty, so behavior is byte-identical to before. Covered by unit tests.

Backend: adds an opt-in base_url setting (plus forwarded_allow_ips). When set, the app is wrapped in SubPathASGIMiddleware, which strips the prefix from the request path and advertises root_path, handling both proxy styles (preserve and strip). uvicorn's root_path is intentionally not used, because it prepends the prefix to the request path and breaks the preserve case (the prefix would appear twice). Default (unset) leaves existing installs completely unchanged.

Two deployment scenarios are supported:

  • Strip proxy (proxy removes the sub-path before forwarding): works with zero backend config via frontend auto-detection. Setting base_url is optional but recommended so /docs + openapi URLs are correct.
  • Preserve proxy (proxy forwards the sub-path unchanged): requires base_url so the middleware can strip the prefix for routing.

QA Instructions

Tested end-to-end with Docker + a Caddy reverse proxy in three configurations; every endpoint was checked for the correct content type and body (not just status code):

Configuration UI API (real JSON) openapi.json locales /docs WebSocket
Preserve proxy + base_url=/invoke ✅ (refs /invoke/openapi.json) ✅ (valid sid)
Strip proxy + base_url=/invoke
Strip proxy, zero-config (base_url unset)

The frontend prefix-detection logic is also covered by unit tests (baseUrl.test.ts).

To reproduce locally (production build required — the dev server has no /assets/ segment, so auto-detection is a no-op there):

Strip proxy (Caddy):

:8080 {
    handle_path /invoke/* {
        reverse_proxy 127.0.0.1:9090
    }
    redir /invoke /invoke/ 308
}

Leave base_url unset (or set base_url: /invoke to also fix /docs).

Preserve proxy (nginx):

location /invoke/ {
    proxy_pass http://127.0.0.1:9090/invoke/;   # note: NO trailing-slash stripping
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}
location = /invoke { return 301 /invoke/; }

Set base_url: /invoke and forwarded_allow_ips: <proxy-ip> in invokeai.yaml.

Then browse http://localhost:8080/invoke/ and confirm: UI loads, generations stream progress (websocket), language switching loads locales, and a reload restores client state. Also verify a regression check at the domain root (http://localhost:9090/) still works with base_url unset.

Merge Plan

Normal merge. No DB schema or redux slice changes. The config schema gains two opt-in fields (base_url, forwarded_allow_ips); both default to a no-op, so no migration is needed.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

The web frontend derived all URLs from window.location.origin and hardcoded
paths (/api, /ws/socket.io, /locales, /openapi.json), so running Invoke behind
a reverse proxy under a sub-path (e.g. https://host/invoke/) broke because the
prefix was lost.

Frontend: add a shared helper (common/util/baseUrl.ts) that auto-detects the
deployment prefix from the entry bundle URL (import.meta.url), relying on Vite's
existing `base: './'`. getBaseUrl(), the socket.io path, i18n loadPath, the
openapi fetch and the React Router basename now all use it. At the domain root
the prefix is empty, so behavior is byte-identical to before. Covered by unit
tests.

Backend: add an opt-in `base_url` setting (plus `forwarded_allow_ips`). When set,
the app is wrapped in SubPathASGIMiddleware which strips the prefix from the
request path and advertises root_path, handling both proxy styles (preserve and
strip). uvicorn's root_path is intentionally not used, as it prepends the prefix
and breaks the preserve case. Default (unset) leaves existing installs unchanged.

Tested end-to-end with Docker + Caddy for preserve, strip, and zero-config strip.
@github-actions github-actions Bot added python PRs that change python files services PRs that change app services frontend PRs that change frontend files labels Jun 26, 2026
@github-actions github-actions Bot added the docs PRs that change docs label Jun 26, 2026
@lstein lstein self-assigned this Jun 28, 2026
@lstein lstein added the 6.14.x label Jun 28, 2026
@lstein lstein moved this to 6.14.x Theme: USER EXPERIENCE in Invoke - Community Roadmap Jun 28, 2026

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this is a well-constructed PR. The import.meta.url auto-detection on the frontend is elegant and unit-tested, the base_url validator normalizes correctly (I exercised '/invoke/', '//invoke//', ' /x ', '', '/' — all behave as documented), defaults are a strict no-op, and the dual proxy-style handling works for the main flows. I ran the new frontend tests (6/6 pass) and exercised the middleware against Starlette 0.48/FastAPI with a TestClient to verify behavior empirically.

Requesting changes for one functional gap plus two confirmed backend edge cases; details in the inline comments where the code is in the diff.

1. Login/setup flows break under a sub-path (blocking)

The PR sets the React Router basename, but two auth components navigate with hard-coded absolute paths that bypass it (these files aren't in the diff, so noting here rather than inline):

  • invokeai/frontend/web/src/features/auth/components/LoginPage.tsx:65window.location.href = '/app' (the deliberate full reload for multiuser state isolation)
  • invokeai/frontend/web/src/features/auth/components/AdministratorSetup.tsx:63window.location.href = '/login'

Under https://host/invoke/, completing login or admin setup navigates to https://host/app / https://host/login — outside the proxied prefix, typically a proxy 404. Since these are the entry points when auth is enabled, sub-path support is broken for multiuser installs. Fix is small and keeps the full-reload semantics:

window.location.href = `${getBasePath()}/app`;  // and '/login' in AdministratorSetup

2. Server-generated redirects lose the prefix (confirmed empirically)

Because the middleware strips base_path from scope["path"] while Starlette builds redirect URLs from scope["path"] alone, every server-generated redirect drops the prefix. TestClient results with base_url=/invoke:

GET /invoke/api/v1/images        -> 200                                        (routing ✓)
GET /invoke/api/v1/boards        -> 307 Location: http://host/api/v1/boards/   (prefix lost)
GET /invoke/?__theme=dark        -> 307 Location: /                            (prefix lost)

The SPA always sends canonical URLs so it won't hit the trailing-slash 307s, but direct API consumers will — and the ?__theme=dark case is exactly what RedirectRootWithQueryStringMiddleware exists for, and it now lands the user at the domain root. See the inline comment on SubPathASGIMiddleware for a suggested fix (prepend instead of strip).

3. base_url colliding with a real route prefix silently bricks the app (confirmed)

With base_url: /api, both delivery styles 404: /api/v1/images gets eaten by the strip logic, and /api/api/v1/images gets double-stripped by Starlette's get_route_path (which removes root_path from path a second time). Same for /ws, /static, /docs, /redoc, /locales, /assets, /openapi.json. The failure mode is a completely dead server with no hint why — see inline comment on the validator.

Minor

  • No backend tests for the middleware or validator, and both are trivially unit-testable — given the subtle proxy-style matrix above, a small test would protect this from regressing. Happy to share the TestClient harness I used for the results above.

Comment thread invokeai/app/api_app.py Outdated
Comment thread invokeai/app/services/config/config_default.py
Comment thread invokeai/frontend/web/src/common/util/baseUrl.ts
Comment thread invokeai/app/run_app.py Outdated

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See inline comments above for issues picked up during code review.

Address blockers from PR review for reverse-proxy sub-path support:

- Auth flows: LoginPage/AdministratorSetup navigated to hardcoded
  '/app' and '/login', bypassing the router basename and breaking
  multiuser installs under a sub-path. Prefix with getBasePath().
- SubPathASGIMiddleware: invert the rewrite — prepend the prefix for
  strip-style requests instead of stripping it for preserve-style ones,
  so scope["path"] holds the full public path (ASGI-compliant) and
  Starlette keeps the prefix on server-generated redirects (trailing-
  slash 307s, ?__theme=dark). RedirectRootWithQueryStringMiddleware is
  now root_path-aware.
- Config: reject base_url whose first segment collides with a real
  route prefix (api, ws, static, docs, redoc, openapi.json, locales,
  assets) instead of silently bricking the server.
- baseUrl: fall back to the app origin when the bundle is served from a
  foreign (CDN) origin; use lastIndexOf('/assets/') for robustness.
- run_app: move typing import to top, drop redundant proxy_headers=True.
- Add backend tests for the middleware (both proxy styles) and the
  base_url validator; extend baseUrl frontend tests.
@github-actions github-actions Bot added the python-tests PRs that change python tests label Jul 15, 2026
engine.io is not root_path-aware and matches the raw scope["path"]
against its configured socketio_path. Once base_url sets root_path,
Starlette hands the mounted app the full public path
(/invoke/ws/socket.io), so every handshake 404'd and the UI lost all
real-time updates behind the proxy. Fold base_url into socketio_path.
@github-actions github-actions Bot added the api label Jul 16, 2026
@Pfannkuchensack
Pfannkuchensack requested a review from lstein July 16, 2026 00:15
Resolve conflict in useSocketIO.ts between the sub-path socket URL/path
derivation (this PR) and the per-user auth-hydration gating from invoke-ai#9358:
keep both import lines; the hook body combines cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All review comments addressed. LGTM

@lstein

lstein commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Pushed a merge of main to resolve the conflict with #9358 (per-user socket event scoping), which landed after your last merge of main.

Also: nice catch on the engine.io handshake 404 — the prepend approach I suggested did introduce that, and folding base_url into socketio_path is the right fix. All review findings are addressed; approval incoming.

@lstein
lstein enabled auto-merge (squash) July 17, 2026 14:57
@lstein
lstein merged commit fde1863 into invoke-ai:main Jul 17, 2026
17 checks passed
@Pfannkuchensack
Pfannkuchensack deleted the fix/proxy_usage branch July 17, 2026 15:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.x api docs PRs that change docs frontend PRs that change frontend files python PRs that change python files python-tests PRs that change python tests services PRs that change app services

Projects

Status: 6.14.x Theme: USER EXPERIENCE

Development

Successfully merging this pull request may close these issues.

2 participants